#!/bin/sh
#
# page -- A simple password manager using age written in POSIX sh.
#
# Copyright (C) 2020-2024  Ricardo García Jiménez <ricardogj08@riseup.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
# ========================================================================
#
# The MIT License (MIT)
#
# Copyright (c) 2016-2019 Dylan Araps
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#

PAGE_VERSION=2.0

# Muestra mensajes de errores.
error() {
  printf '%s.\n' "$1" >&2
  exit 1
}

# Muestra un mensaje de ayuda.
shelp() { printf '%s' "\
page ${PAGE_VERSION} - A simple password manager using age written in POSIX sh.

Synopsis:
  page [OPTION]

Options:
  -a <NAME> - Add a new password.
  -c <NAME> - Copy a password to the clipboard.
  -d <NAME> - Delete a password.
  -g        - Generate a master key.
  -l        - List all password names.
  -s <NAME> - Show a password.

Environment vars:
  - Storage location:  export PAGE_DIR=~/.local/share/page
  - Password length:   export PAGE_LENGTH=12
  - Password pattern:  export PAGE_PATTERN=[:graph:]
  - Clipboard tool:    export PAGE_CLIPBOARD='xclip -sel c'
  - Clipboard timeout: export PAGE_TIMEOUT=15 ('off' to disable)
"
exit 0
}

# Comprueba si un string coincide con un patrón de búsqueda.
# shellcheck disable=SC2254
match() {
  case $1 in
    $2) return 0;;
  esac

  return 1
}

# Genera un llave maestra de cifrado.
generate_key() {
  age-keygen | age -p -o key.age || rm -f key.age
}

# Confirma una acción.
confirm() {
  printf '%s [y/n]: ' "$1"

  # Habilita la entrada sin formato
  # (stdin no espera un return).
  stty -icanon

  # Lee un solo byte desde stdin.
  answer=$(dd ibs=1 count=1 2>/dev/null)

  # Habilita la entrada con formato.
  stty icanon

  printf '\n'

  match "${answer}" '[yY]'
}

# Lee un string desde stdin sin imprimirlo.
sread() {
  printf '%s: ' "$2"

  stty -echo

  read -r "$1"

  stty echo

  printf '\n'
}

# Agrega una nueva contraseña.
add_pass() {
  if confirm "Generate a random password?"; then
    # Genera una contraseña aleatoria
    # desde una serie de bytes aleatorios.
    pass=$(LC_ALL=C tr -dc "${PAGE_PATTERN}" </dev/urandom |
      dd ibs=1 obs=1 count="${PAGE_LENGTH}" 2>/dev/null)
  else
    sread pass "Enter password"
    sread pass2 "Enter password (again)"

    # shellcheck disable=SC2154
    [ "${pass}" = "${pass2}" ] || error "Passwords don't match"
  fi

  [ -z "${pass}" ] && error "Failed to generate a password"

  # Obtiene la llave pública de cifrado.
  recipient=$(age -d key.age | age-keygen -y || kill 0)

  # Utiliza 'age' para almacenar una contraseña
  # dentro de un archivo cifrado.
  age -r "${recipient}" -o "$1.age" <<-EOF &&
		${pass}
	EOF

  printf '%s\n' "Saved '$1' to the store."
}

# Lista todas las contraseñas almacenadas.
list_pass() {
  find . -type f -name \*.age -a ! -name key.age |
    sed 's/..//;s/\.age$//'
}

# Descifra una contraseña con 'age'.
decrypt_pass() {
  age -d -i key.age "$1.age" || kill 0
}

# Elimina una contraseña y sus categorías vacías.
delete_pass() {
  confirm "Delete pass name '$1'?" && {
    rm -f "$1.age"

    rmdir -p "${1%/*}" 2>/dev/null
  }
}

# Copia una contraseña al portapapeles.
copy_pass() {
  decrypt_pass "$1" | ${PAGE_CLIPBOARD}

  # Ejecuta un proceso en segundo plano para limpiar
  # el portapapeles después de un tiempo determinado.
  [ "${PAGE_TIMEOUT}" != off ] && {
    printf 'Clearing clipboard in "%s" seconds.\n' "${PAGE_TIMEOUT}"

    sleep "${PAGE_TIMEOUT}" || kill 0

    ${PAGE_CLIPBOARD} </dev/null
  } &
}

# Función principal de la aplicación.
main() {
  # Desactiva el modo debug.
  set +x

  # Desactiva la expansión de nombres de archivos.
  set -f

  command -v age >/dev/null 2>&1 ||
    error "'age' not found"

  # Ubicación de almacenamiento por defecto.
  : "${PAGE_DIR:=${XDG_DATA_HOME:-$HOME/.local/share}/page}"

  mkdir -p "${PAGE_DIR}" ||
    error "Couldn't create password directory"

  cd "${PAGE_DIR}" ||
    error "Couldn't access password directory"

  match "$1" '-[acds]' && [ -z "$2" ] &&
    error "Option $1 requires an argument"

  match "$2" '*/*' && {
    match "$2" '*../*' ||
    match "$2" '*./*' &&
      error "Category went out of bounds"
  }

  match "$2" '/*' &&
    error "Category can't start with '/'"

  match "$1" '-[acds]' && [ ! -f key.age ] &&
    error "Couldn't find master key"

  match "$1" '-[cds]' && [ ! -f "$2.age" ] &&
    error "Pass name '$2' doesn't exist"

  match "$1" '-a' && [ -f "$2.age" ] &&
    error "Pass name '$2' already exists"

  match "$1" '-g' && [ -f key.age ] &&
    error "Master key already exists"

  match "$1" '-[cds]' && [ "$2.age" = key.age ] &&
    error "You can't use your master key"

  match "$2" '*/*' && {
    mkdir -p "${2%/*}" ||
      error "Couldn't create category '${2%/*}'"
  }

  # Restringe los permisos de los archivos generados
  # solo al usuario actual (chmod 600).
  umask 077

  # Asegura configurar la terminal a un estado
  # utilizable al finalizar el script o con Ctrl+C
  [ -t 1 ] && trap 'stty echo icanon' INT EXIT

  # Configuración por defecto.
  : "${PAGE_PATTERN:=[:graph:]}"
  : "${PAGE_LENGTH:=12}"
  : "${PAGE_CLIPBOARD:=xclip -sel c}"
  : "${PAGE_TIMEOUT:=15}"

  case $1 in
    -a) add_pass "$2";;
    -c) copy_pass "$2";;
    -d) delete_pass "$2";;
    -g) generate_key;;
    -l) list_pass;;
    -s) decrypt_pass "$2";;
    *) shelp;;
  esac
}

[ -n "$1" ] || shelp && main "$@"
